Skip to content

fix: clean up stale MCP sessions to bound memory growth#71

Open
Zhenyu98 wants to merge 2 commits into
Waishnav:mainfrom
Zhenyu98:fix/stale-mcp-session-cleanup
Open

fix: clean up stale MCP sessions to bound memory growth#71
Zhenyu98 wants to merge 2 commits into
Waishnav:mainfrom
Zhenyu98:fix/stale-mcp-session-cleanup

Conversation

@Zhenyu98

@Zhenyu98 Zhenyu98 commented Jul 11, 2026

Copy link
Copy Markdown

Summary

  • add an McpSessionRegistry that tracks the last activity time for each Streamable HTTP transport
  • close sessions after 24 hours of inactivity with a conservative five-minute cleanup sweep
  • close and release remaining transports during server shutdown
  • log whether a session closed normally, timed out, or failed to close
  • add regression coverage for activity refresh, idle cleanup, close failures, explicit removal, and shutdown cleanup

Motivation

createServer() currently keeps every initialized MCP transport in a process-lifetime Map and removes it only when transport.onclose fires. Some MCP clients reconnect without explicitly closing the previous transport, so abandoned transports and their connected MCP server/tool objects can remain strongly referenced indefinitely.

In one long-running deployment, the logs contained 96 mcp_session_created events and zero mcp_session_closed events. The DevSpace process grew from about 218 MB of private memory immediately after a restart to about 2.55 GB after roughly two weeks, then returned to the baseline after restarting. This observation does not prove that every retained byte came from MCP transports, but it demonstrates the unbounded-retention risk and motivated this memory-usage fix.

Behavior

  • every request using an existing session refreshes its last-activity timestamp
  • sessions inactive for 24 hours are removed from the registry before transport.close() is awaited, so references are released even when closing fails
  • the 24-hour timeout is intentionally conservative to avoid disrupting normal pauses in a ChatGPT or Claude conversation
  • a client that attempts to reuse an expired session receives the existing Unknown MCP session response and can initialize a new session
  • normal transport.onclose handling remains supported and no duplicate close log is emitted

Verification

  • npm test
  • npm run typecheck
  • npm run build
  • git diff upstream/main...HEAD --check

The change is intentionally limited to MCP transport lifecycle management. Workspace-cache eviction can be evaluated separately.

Summary to CodeRabbit

  • New Features

    • Added an in-memory MCP session registry with idle-timeout cleanup and bulk shutdown.
    • Improved graceful shutdown by draining the HTTP server, awaiting async app cleanup, and reporting per-transport close outcomes.
  • Bug Fixes

    • Ensured session removal is respected during shutdown (removed sessions are no longer closed).
  • Tests

    • Added coverage for idle cleanup, transport close failures, registry shutdown semantics, delayed HTTP draining, and HTTP close error propagation.
    • Expanded the test runner to include the new shutdown and session tests.

@coderabbitai

coderabbitai Bot commented Jul 11, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Adds an MCP session registry with idle cleanup and per-session close results. Server and CLI shutdown now await transport, application, and HTTP cleanup, with guarded signal handling and lifecycle tests for ordering, delays, and errors.

Changes

MCP session and server shutdown lifecycle

Layer / File(s) Summary
Session registry contract and behavior
src/mcp-sessions.ts, src/mcp-sessions.test.ts, package.json
Adds activity tracking, idle/all-session closure, per-session errors, and coverage for cleanup, removal, failures, delayed closes, and test-script registration.
HTTP shutdown primitive and validation
src/server-shutdown.ts, src/server-shutdown.test.ts
Adds asynchronous HTTP shutdown that awaits application cleanup and HTTP draining, with ordering, delay, success, and error tests.
Server and CLI lifecycle integration
src/server.ts, src/cli.ts
Integrates registry-based transport cleanup, periodic idle closure, structured logging, asynchronous server shutdown, and guarded signal handling.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Sequence Diagram(s)

sequenceDiagram
  participant SignalHandler
  participant shutdownHttpServer
  participant RunningServer
  participant McpSessionRegistry
  participant HTTPServer
  SignalHandler->>shutdownHttpServer: request shutdown
  shutdownHttpServer->>RunningServer: close application
  RunningServer->>McpSessionRegistry: closeAll()
  McpSessionRegistry->>McpSessionRegistry: close transports
  shutdownHttpServer->>HTTPServer: close(callback)
  HTTPServer-->>shutdownHttpServer: drain complete or error
Loading

Poem

A bunny tracks each session bright,
Sweeps stale doors at closing night.
Errors hop into the report,
HTTP waits before the fort.
Tests cheer: “Shutdown’s done just right!”

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title matches the main change: adding stale MCP session cleanup to prevent unbounded memory growth.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@Zhenyu98

Copy link
Copy Markdown
Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jul 11, 2026

Copy link
Copy Markdown
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@Zhenyu98 Zhenyu98 marked this pull request as ready for review July 11, 2026 10:09
@Waishnav

Copy link
Copy Markdown
Owner

Thanks for the PR @Zhenyu98 — the idle-session cleanup is a meaningful RAM-usage improvement. The current session map can retain abandoned transports and their connected MCP server object graphs indefinitely, so bounding that lifecycle is a valuable fix for long-running DevSpace processes.

The registry implementation itself looks sound, but the server-shutdown cleanup is currently ordered in a way that can prevent it from running.

Both entrypoints call close() only inside the callback passed to httpServer.close(). Node waits for active HTTP requests to finish before invoking that callback. An MCP SSE request can remain active until its StreamableHTTPServerTransport is closed, but those transports are only closed by close(). This creates a circular wait: HTTP shutdown waits for the SSE request, while transport shutdown waits for the HTTP shutdown callback.

There is also a second race: close() starts transports.closeAll() without awaiting it, and the caller immediately invokes process.exit(0). That can terminate the process before transports finish closing or shutdown logs are emitted.

Could we make RunningServer.close() return Promise<void> and await transports.closeAll()? The signal handler can start httpServer.close() and then await application cleanup while the HTTP server is draining:

const shutdown = async () => {
  const httpClosed = new Promise<void>((resolve, reject) => {
    httpServer.close((error) => {
      if (error) reject(error);
      else resolve();
    });
  });

  await close();
  await httpClosed;
};

This lets closing the MCP transports terminate active SSE responses, which in turn allows httpServer.close() to complete. It also avoids relying on asynchronous cleanup after process.exit().

@Zhenyu98

Zhenyu98 commented Jul 13, 2026

Copy link
Copy Markdown
Author

Thanks for digging into this — that was a sharp catch. @Waishnav

The original change was focused on bounding the lifetime of abandoned MCP sessions. Your shutdown analysis connected that fix to the server’s termination path and exposed two issues that had been missed: the circular wait between httpServer.close() and an active SSE transport, and the race between asynchronous cleanup and process.exit(0).

Both cases were reproduced, and the implementation was updated in ee372e7. RunningServer.close() now returns a memoized Promise<void> and waits for all MCP transports to close. In both entrypoints, HTTP draining is started first, application cleanup runs immediately, and the process exits only after both cleanup and HTTP drain have completed.

Focused regression tests were added for shutdown ordering, delayed transport closure, HTTP drain completion, and HTTP close failures. The branch was also rebased onto the current main (b027795). The full test suite, typecheck, production build, and git diff --check all pass.

@Zhenyu98 Zhenyu98 force-pushed the fix/stale-mcp-session-cleanup branch from 880aeea to ee372e7 Compare July 13, 2026 17:18

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (2)
src/server-shutdown.ts (1)

9-17: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Consider adding a drain timeout for production resilience.

If a client holds an HTTP connection open indefinitely, httpClosed never resolves and shutdownHttpServer hangs forever. A configurable timeout with httpServer.closeAllConnections() (Node 18.2+) as a fallback would bound shutdown latency. This is a design trade-off, not a defect — flagging for awareness.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/server-shutdown.ts` around lines 9 - 17, Add a configurable drain timeout
around the httpClosed wait in shutdownHttpServer, and when it expires invoke
httpServer.closeAllConnections() before completing shutdown. Preserve normal
graceful closure when connections drain within the timeout, and use the existing
HTTP server lifecycle symbols rather than changing closeApplication.
src/cli.ts (1)

232-246: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Extract shared shutdown handler to avoid duplication with server.ts.

The shuttingDown guard, shutdown(), handleShutdown(), and process.once pattern is identical to server.ts:1845-1860. A shared helper like installShutdownHandlers(httpServer, close) would prevent these copies from diverging.

♻️ Suggested extraction
+ // src/shutdown-handler.ts
+ import type { ClosableHttpServer } from "./server-shutdown.js";
+ import { shutdownHttpServer } from "./server-shutdown.js";
+
+ export function installShutdownHandlers(
+   httpServer: ClosableHttpServer,
+   close: () => Promise<void>,
+ ): void {
+   let shuttingDown = false;
+   const shutdown = async () => {
+     if (shuttingDown) return;
+     shuttingDown = true;
+     await shutdownHttpServer(httpServer, close);
+     process.exit(0);
+   };
+   const handleShutdown = () => {
+     void shutdown().catch((error) => {
+       console.error("devspace shutdown failed", error);
+       process.exit(1);
+     });
+   };
+   process.once("SIGINT", handleShutdown);
+   process.once("SIGTERM", handleShutdown);
+ }

Then in both cli.ts and server.ts:

-  let shuttingDown = false;
-  const shutdown = async () => {
-    if (shuttingDown) return;
-    shuttingDown = true;
-    await shutdownHttpServer(httpServer, close);
-    process.exit(0);
-  };
-  const handleShutdown = () => {
-    void shutdown().catch((error) => {
-      console.error("devspace shutdown failed", error);
-      process.exit(1);
-    });
-  };
-  process.once("SIGINT", handleShutdown);
-  process.once("SIGTERM", handleShutdown);
+  installShutdownHandlers(httpServer, close);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/cli.ts` around lines 232 - 246, Extract the duplicated shutdown flow into
a shared installShutdownHandlers helper, including the shuttingDown guard,
shutdown() cleanup, handleShutdown() error handling, and SIGINT/SIGTERM
process.once registrations. Replace the inline implementations in both cli.ts
and server.ts with calls to this helper, passing httpServer and close while
preserving existing exit behavior.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@src/cli.ts`:
- Around line 232-246: Extract the duplicated shutdown flow into a shared
installShutdownHandlers helper, including the shuttingDown guard, shutdown()
cleanup, handleShutdown() error handling, and SIGINT/SIGTERM process.once
registrations. Replace the inline implementations in both cli.ts and server.ts
with calls to this helper, passing httpServer and close while preserving
existing exit behavior.

In `@src/server-shutdown.ts`:
- Around line 9-17: Add a configurable drain timeout around the httpClosed wait
in shutdownHttpServer, and when it expires invoke
httpServer.closeAllConnections() before completing shutdown. Preserve normal
graceful closure when connections drain within the timeout, and use the existing
HTTP server lifecycle symbols rather than changing closeApplication.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 904cec6a-8d64-42f5-9ef2-e788d9aafece

📥 Commits

Reviewing files that changed from the base of the PR and between c735b1c and 880aeea.

📒 Files selected for processing (6)
  • package.json
  • src/cli.ts
  • src/mcp-sessions.test.ts
  • src/server-shutdown.test.ts
  • src/server-shutdown.ts
  • src/server.ts
🚧 Files skipped from review as they are similar to previous changes (2)
  • package.json
  • src/server.ts

NikitaMGrimm added a commit to NikitaMGrimm/devspace that referenced this pull request Jul 14, 2026
Adapt Waishnav#71 to the maintained fork and make the idle timeout configurable.

Co-authored-by: Zhenyu Wu <[email protected]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants